Repository Public
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// /api/admin/attachments/[id] — téléchargement d'une pièce jointe.3// Les fichiers vivent HORS de /public (data/contact-uploads) : seul un admin4// autorisé à voir la demande peut les lire. Nom stocké aléatoire — le nom5// d'origine n'est restitué que dans l'en-tête Content-Disposition.6import { NextRequest, NextResponse } from "next/server";7import fs from "fs/promises";8import path from "path";9import { findAttachment, findSubmissionById } from "@/lib/comms/db";10import { getAdminSession, canViewSubmission } from "@/lib/comms/admin-auth";1112const UPLOAD_DIR = path.join(process.cwd(), "data", "contact-uploads");1314export async function GET(15 _req: NextRequest,16 { params }: { params: Promise<{ id: string }> },17) {18 const session = await getAdminSession();19 if (!session)20 return NextResponse.json({ error: "Non autorisé." }, { status: 401 });21 const id = Number((await params).id);22 const att = Number.isInteger(id) ? findAttachment(id) : undefined;23 const sub = att ? findSubmissionById(att.submission_id) : undefined;24 if (!att || !sub || !canViewSubmission(session, sub))25 return NextResponse.json({ error: "Fichier introuvable." }, { status: 404 });2627 // stored_name est produit par nous ("2026/KA-…-abcd.pdf") — on neutralise28 // quand même toute traversée de chemin.29 const safe = path.normalize(att.stored_name).replace(/^(\.\.[/\\])+/, "");30 const full = path.join(UPLOAD_DIR, safe);31 if (!full.startsWith(UPLOAD_DIR))32 return NextResponse.json({ error: "Chemin refusé." }, { status: 400 });33 let buf: Buffer;34 try {35 buf = await fs.readFile(full);36 } catch {37 return NextResponse.json({ error: "Fichier manquant sur le disque." }, { status: 404 });38 }39 const filename = att.original_name.replace(/["\r\n]/g, "");40 return new NextResponse(new Uint8Array(buf), {41 headers: {42 "Content-Type": att.mime,43 "Content-Disposition": `attachment; filename="${filename}"`,44 "Cache-Control": "private, no-store",45 },46 });47}48